<!--
  - OpenCV-轮廓处理业务
  - 化学动力学计算机视觉解决方案项目的子业务实现
  - 详见简介
 -->
 
<!-- 视图层 -->
<template>

<!-- 功能简介 -->
<uni-card title="功能简介">
  <!-- 通告栏 -->
  <uni-notice-bar text=
  "操作方法（电脑、手机均可操作）：
  ① 读取所拍摄的样品图片。
  ② 滑动阈值调节选框，并预览处理后的图片。
  ③ 可反复调整参数预览，直至参数合适，然后下载详细数据。"
  />
  <!-- 文字 -->
  <view><text>
  　　化学动力学计算机视觉解决方案项目的子业务实现。侧重于解决批量数据的比色法操作时，识别样品轮廓并进一步得到准确的颜色区域这一需求。\n
  　　借助OpenCV计算机视觉模型库，实现读取图片、二值化处理、轮廓检测、轮廓拟合、轮廓面积计算、轮廓中心坐标计算、轮廓面积与中心坐标数据导出等操作，以满足图像比色分析法的业务需求。
  </text></view>
  <!-- 二级标题：参考文献 -->
  <uni-section
    title="参考链接"
    type="circle"
    padding="0 1em"
  >
  <!-- 官方手册：文字 -->
  <view><text>
    　　官方手册：
  </text></view>
  <!-- 官方手册：链接 -->
  <uni-link
    text="https://docs.opencv.org/"
    href="https://docs.opencv.org/4.10.0/d5/d10/tutorial_js_root.html"
    class="center" color="blue"
  />
  <!-- 参考库：文字 -->
  <view><text>
    　　参考库：
  </text></view>
  <!-- 参考库：链接 -->
  <uni-link
    text="https://www.npmjs.com/"
    href="https://www.npmjs.com/package/@techstark/opencv-js"
    class="center" color="blue"
  />
  </uni-section>
</uni-card>

<!-- 读取样品照片 -->
<uni-card title="读取样品照片">
  <!-- 通告栏 -->
  <uni-notice-bar text=
  "用于计算机视觉处理的照片。"
  />
  <!-- 二级标题：样品水滴图片 -->
  <uni-section
  title="点击读取样品照片"
  type="circle"
  padding="0 1em"
  >
  <!-- 文件上传容器 -->
  <uni-file-picker
    fileMediatype="image"
    limit="1" return-type="object" :del-icon="false"
    :auto-upload="false" :disable-preview="true"
    @select="(event) => { readPicFile(event.tempFiles[0]) }"
  />
  </uni-section>
</uni-card>

<!-- 折叠面板 -->
<uni-collapse>

<!-- 灰度化&二值化 -->
<uni-card title="灰度化&二值化">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "运用OpenCV计算机视觉库的颜色空间转换算法及图像通道二值化模型，对图片进行预处理。"
  />
  <!-- 文字内容 -->
  <view><text>
  　　为方便找到轮廓线，需要对原始图片进行[灰度化]及[二值化]操作。\n
  　　灰度化操作即合并RGBA通道为Gray灰度通道，此操作可自动完成。\n
  　　二值化操作即以0为黑，255为白。设定一个[二值化阈值]，对于灰度化图片的每个像素点，高于阈值的均赋值为白(255)，低于阈值的均赋值为黑(0)。\n
  　　阈值越低(越黑)，则越多深色像素被定义为白；反之阈值越高(越白)，则越多浅色像素被定义为黑。
  </text></view>
  <!-- 下拉框容器 -->
  <uni-collapse-item
    @click="cvThreshold()"
    :disabled="!isGetPic"
    title="施用参数并渲染预览图"
  >
  <!-- 二值化预览图容器 -->
  <view class="center">
    <!-- 二值化预览图 -->
    <canvas
      id="canvas-threshold" canvas-id="canvas-threshold"
      :style="'width: ' + canvasW + 'rpx; height: ' + canvasH + 'rpx'"
    />
  </view>
  </uni-collapse-item>
  <!-- 二级标题：二值化阈值调节 -->
  <uni-section
    title="二值化阈值调节"
    type="circle"
    padding="0 1em"
  >
  <!-- 滑动选择器的容器 -->
  <slider
    :disabled="!isGetPic"
    :value="argObj.thresh"
    @change="(event) => {
    argObj.thresh = event.detail.value
    cvThreshold()
    }"
    :min="1"
    :max="254"
    :step="1"
    show-value
  />
  </uni-section>
</uni-card>

<!-- “近圆率”&“面积滤过率” -->
<uni-card title="“近圆率”&“面积滤过率”">
  <!-- 通告栏 -->
  <uni-notice-bar text=
  "运用OpenCV计算机视觉库的轮廓识别模型，结合自定义的“近圆率”与“面积滤过率”参数，对图片中有效孔区域进行筛选。"
  />
  <!-- 文字内容 -->
  <view><text>
  　　对[二值化图片]执行[全局轮廓识别]处理后，即可生成轮廓。然而所生成的轮廓包含较多不需要的图形内容，需进行必要的筛选工作。据观察，有效数据轮廓更接近圆形，且面积较小。因此所选用的筛选策略为：\n
  　　① 计算查找到的所有轮廓的[轮廓面积]与其[最小外接圆]的面积。\n
  　　② 定义“近圆率”：[近圆率] = [轮廓面积] ÷ [最小外接圆面积]。[近圆率]越接近1，则轮廓越接近圆形。\n
  　　③ 设定一个[近圆率阈值]：对于每个轮廓，[近圆率]低于阈值的则弃去，仅保留[近圆率]高于阈值的轮廓。\n
  　　④ 对筛选后的轮廓，按面积进行排序。设定一个[面积滤过率]以过滤掉面积最大的部分轮廓，保留小轮廓。滤过率越低，滤得的轮廓数越少；滤过率越高，滤得的轮廓数越多。
  </text></view>
  <!-- 下拉框容器 -->
  <uni-collapse-item
  :disabled="!isGetPic"
  @click="contoursHandle()"
  title="施用参数并渲染预览图"
  >
  <!-- 轮廓图容器 -->
  <view class="center">
    <!-- 有效圆识别预览图 -->
    <canvas
    id="canvas-contours" canvas-id="canvas-contours"
    :style="'width: ' + canvasW + 'rpx; height: ' + canvasH + 'rpx'"
    />
  </view>
  </uni-collapse-item>
  <!-- 二级标题：“近圆率”阈值调节 -->
  <uni-section
  title="“近圆率”阈值调节"
  type="circle"
  padding="0 1em"
  >
  <!-- “近圆率”阈值调节-滑动选择器的容器 -->
  <slider
    :disabled="!isGetPic"
    :value="argObj.minCircleRate"
    @change="(event) => {
    argObj.minCircleRate = event.detail.value
    contoursHandle()
    }"
    :min="0.01"
    :max="1"
    :step="0.01"
    show-value
  />
  </uni-section>
  <!-- 二级标题：“面积滤过率”调节 -->
  <uni-section
  title="“面积滤过率”调节"
  type="circle"
  padding="0 1em"
  >
  <!-- “面积滤过率”调节-滑动选择器的容器 -->
  <slider
    :disabled="!isGetPic"
    :value="argObj.filterRate"
    @change="(event) => {
    argObj.filterRate = event.detail.value
    contoursHandle()
    }"
    :min="0.00"
    :max="1"
    :step="0.01"
    show-value
  />
  </uni-section>
</uni-card>

</uni-collapse>

<!-- 原始数据&最终数据 -->
<uni-card title="原始数据&最终数据">
  <!-- 通告栏 -->
  <uni-notice-bar text=
  "根据预览调参的图片信息，得到所需的详细原始数据或最终结果数据文件。"
  />
  <!-- 文字内容 -->
  <view><text>
  　　调整完轮廓后，可下载原始数据，也可绘图预览。\n
  　　原始数据包含每一行、每一列识别到的圆，沿质心距离逐步计算的R、G、B的均值AVE和标准差SD。\n
  　　绘图预览内容为各轮廓RGB值随质心距离R的标准差SD值的变化趋势，可借此判断“SD阈值”的合理取值。确定合适的“SD阈值”后，可下载最终结果数据。\n
  　　最终结果数据将以R、G、B均不超过SD阈值的距离计算得到各行、列识别到的圆的RGB均值。
  </text></view>
  <!-- 下载原始数据按钮 -->
  <view class="center"><button
  v-if="isGetMomentCircles"
  :disabled="!isGetMomentCircles"
  @click="originResultToXlsx()"
  size="mini" type="primary"
  >
  下载原始数据
  </button></view>
  <!-- 绘图预览容器按钮 -->
  <view
  v-if="isGetMomentCircles"
  class="center"
  >
  <!-- 绘图预览按钮 -->
  <button
    :disabled="!isGetMomentCircles"
    @click="originResultToLinechart()"
    size="mini" type="primary"
  >
    SD-R绘图预览
  </button>
  <!-- 打开图表栏按钮 -->
  <button
    :disabled="!isGetMomentCircles"
    @click="showVisor()"
    size="mini" type="primary"
  >
    打开图表栏
  </button>
  </view>
  <!-- 二级标题：“SD阈值”调节 -->
  <uni-section
    title="“SD阈值”调节"
    type="circle"
    padding="0 1em"
  >
  <!-- 滑动选择器的容器 -->
  <slider
    :disabled="!isGetMomentCircles"
    :value="argObj.maxSd"
    @change="(event) => {
    argObj.maxSd = event.detail.value
    }"
    :min="0.1"
    :max="20"
    :step="0.1"
    show-value
  />
  <!-- 最终数据下载按钮 -->
  <view class="center"><button
    :disabled="!isGetMomentCircles"
    @click="finalResultToXlsx()"
    size="mini" type="primary"
  >
    {{ isGetMomentCircles ? "下载最终结果数据" : "请先完成图片处理"}}
  </button></view>
  </uni-section>

</uni-card>

<!-- 页脚容器 -->
<view class="center"><text>
  \n
  参赛作品-计算机视觉助力动力学比色实验
</text></view>

</template>


<!-- 逻辑层 -->
<script setup>
// 导入vue的框架方法
import { onMounted, shallowRef } from "vue"
// 导入opencv库
import cv from "@techstark/opencv-js"
// 导入xlsx库
import * as XLSX from "xlsx"
// 导入公共方法库的方法
import {
  // 找DOM对象
  findDom,
  // 打开图表栏
  showVisor,
  // AOA绘图
  aoaTotfvisChart,
  // 下载文件
  downloadFile,
  // 数组转置
  arrayTranspose
} from "../../scripts/app_common.js"


/**
 * @ref对象
 */
// 是否读取到图片
const isGetPic = shallowRef(false)
// 是否获取到处理后的轮廓
const isGetMomentCircles = shallowRef(false)
// 用于自适应的canvas宽/高度（父容器的宽/高度），初始值0
const canvasW = shallowRef(0)
const canvasH = shallowRef(0)

/**
 * @其他对象
 * OpenCV的类，其实例对象初始化时需要以新声明的独立对象接构造器（可能是WSAM的bug？）
 */
// OpenCV的Mat矩阵对象
const matObj = {
  // 原图，不用初始化，读取图片时赋值
  origin: null,
  // 灰度图，需要初始化
  gray: null,
  // 二值化图，需要初始化
  binary: null,
}
// OpenCV的MatVector矩阵数组对象
const matVectorObj = {
  contours: null,
}
// DOM对象
const domObj = {
  // 二值化结果预览
  canvasThreshold: null,
  // 轮廓结果预览
  canvasContours: null,
}
// 参数对象
const argObj = {
  // 二值化阈值
  thresh: 50,
  // 近圆率阈值
  minCircleRate: 0.7,
  // 面积滤过率
  filterRate: 1.00,
  // 轮廓的矩化圆心及最小外接圆半径数组
  momentCircles: null,
  // 最大SD阈值
  maxSd: 2
}

/**
 * @启动方法 生命周期钩子，组件加载完毕后执行
 * 获取各类DOM对象
 */
onMounted(async () => {
  // 二值化作图canvas对象
  domObj.canvasThreshold = await findDom("canvas-threshold", "canvas")
  // 轮廓作图canvas对象
  domObj.canvasContours = await findDom("canvas-contours", "canvas")
  // 自适应canvas的宽度为屏幕宽度的0.8
  canvasW.value = window.innerWidth * 0.8
})

/**
 * @readPicFile 读取图片文件
 * @function async
 * @param { File } file 读取到的文件
 * 读取图片并转为OpenCV的Mat矩阵格式，赋值给全局变量matObj.origin
 */
async function readPicFile(file) { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取图片数据" })
  // 根据得到的图片尺寸计算<canvas>高度
  canvasH.value = canvasW.value * (file.image.height / file.image.width)
  // 构造<img>元素，<img>用[width, height]
  const imgElement = new Image(
  file.image.width,
  file.image.height
  )
  // 在网页内隐藏<img>元素
  imgElement.style.display = "none"
  // 将图片路径挂载在<img>元素上
  imgElement.src = file.url
  // 等待图片加载完成
  await imgElement.decode()
  // 从<img>元素构造Mat矩阵，赋值给全局变量matObj.origin
  // imread => image read
  matObj.origin = cv.imread(imgElement)
  // 改变ref状态
  isGetPic.value = true
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readPicFile()报错: ", error)
  uni.hideLoading()
  uni.showModal({
  showCancel: false,
  title: "读取图片文件报错",
  content: error,
  })
}}

/**
 * @cvGray OpenCV灰值化操作
 * @function
 * 将读取到的图片Mat灰度化
 * 会读取matObj.origin，写入argObj.gray
 * 详见：https://docs.opencv.org/4.10.0/de/d25/imgproc_color_conversions.html
 */
function cvGray() { try {
  // 初始化matObj.gray
  matObj.gray = new cv.Mat()
  // 将图像转为灰度，赋值给全局变量matObj.grey
  // cvtColor => convert color
  cv.cvtColor(
  // 原图（输入）
  matObj.origin,
  // 灰度图（输出）
  matObj.gray,
  // 颜色空间转换代码：color：RGBA to gray
  cv.COLOR_RGBA2GRAY
  )
} catch (error) {
  console.error("cvGray()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "OpenCV灰值化操作报错",
  content: error,
  })
}}

/**
 * @cvThreshold OpenCV二值化操作
 * @function
 * 将读取到的图片Mat灰度化、二值化
 * 会读取灰度图matObj.gray，写入二值图matObj.binary
 * @note 会读取argObj.thresh
 */
function cvThreshold() { try {
  // 防报错的返回框
  if (!isGetPic.value) {
  // 弹出错误框
  uni.showModal({
    showCancel: false,
    title: "未上传图片",
    content: "请先上传图片，再进行图片处理操作",
  })
  // 直接返回
  return
  }
  // 先确保matObj.gray有值，如果没值则灰度化
  if (!matObj.gray || matObj.gray.data.length === 0) {
  // 前置操作：灰度化
  cvGray()
  }
  // 初始化matObj.binary
  matObj.binary = new cv.Mat()
  // 将灰度图转为二值化图，赋值给全局变量matObj.binary
  cv.threshold(
  // 灰度图
  matObj.gray,
  // 输出数组
  matObj.binary,
  // 阈值
  Number(argObj.thresh),
  // 用于THRESH_BINARY和THRESH_BINARY_INV阈值类型的最大值
  255,
  // 阈值类型
  cv.THRESH_BINARY
  )
  // 显示二值化结果
  // imshow => image show
  cv.imshow(domObj.canvasThreshold, matObj.binary)
} catch (error) {
  console.error("cvThreshold()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "OpenCV二值化操作报错",
  content: error,
  })
}}

/**
 * @cvFindContours OpenCV寻找轮廓操作
 * @function
 * 会读取二值图matObj.binary，写入轮廓matVectorObj.contours
 */
function cvFindContours() { try {
  // 先确保matObj.binary有值，如果没值则二值化
  if (!matObj.binary || matObj.binary.data.length === 0) {
  // 前置操作：二值化
  cvThreshold()
  }
  // 初始化matVectorObj.contours
  matVectorObj.contours = new cv.MatVector()
  // 初始化轮廓层次结构hierarchy
  let hierarchy = new cv.Mat()
  // cv.findContours()方法，查找轮廓
  // 详见：https://docs.opencv.org/4.10.0/d5/daa/tutorial_js_contours_begin.html
  cv.findContours(
  // 二值化的Mat图像
  matObj.binary,
  // 轮廓。AOA的Mat对象数组，即MatVector
  matVectorObj.contours,
  // 轮廓层次结构，即各轮廓间的拓扑关系
  // 如圈形图像会有内轮廓和外轮廓，则内外轮廓间会存在包含与被包含等关系
  // 轮廓层次结构即建立“轮廓树”，以层级化的方式描述轮廓之间的关系
  hierarchy,
  // 轮廓检索模式
  // RETR_EXTERNAL => retrieval external - 只检索最外层的轮廓
  cv.RETR_EXTERNAL,
  // 轮廓近似法
  // CHAIN_APPROX_NONE => chain approx none - 不作近似处理，存储所有轮廓点
  cv.CHAIN_APPROX_NONE
  )
  // 销毁轮廓层次结构以回收内存
  // 本业务的轮廓检索模式为“RETR_EXTERNAL”，不存在轮廓层次结构
  hierarchy.delete()
} catch (error) {
  console.error("cvFindContours()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "OpenCV寻找轮廓操作报错",
  content: error,
  })
}}

/**
 * @contoursHandle 轮廓处理操作
 * @function
 * 筛选出合适的轮廓，并导出轮廓的矩化圆心及最小外接圆半径数组
 * 会读取轮廓matVectorObj.contours
 */
function contoursHandle() { try {
  // 防报错的返回框
  if (!isGetPic.value) {
  // 弹出错误框
  uni.showModal({
    showCancel: false,
    title: "未上传图片",
    content: "请先上传图片，再进行图片处理操作",
  })
  // 直接返回
  return
  }
  // 前置操作：寻找轮廓
  cvFindContours()
  // 建立一个空白Mat画布，用于预览
  const contoursHandleMat = cv.Mat.zeros(
  // 用原始图片的宽高
  matObj.origin.rows,
  matObj.origin.cols,
  // 颜色空间类型
  cv.CV_8UC3
  )
  // 定义轮廓的颜色为白色
  const contoursColor = new cv.Scalar(255, 255, 255)
  // 定义最小外接圆的颜色为红色
  const circleColor = new cv.Scalar(255, 0, 0)
  // 轮廓的数量
  const contoursSize = matVectorObj.contours.size()
  // 用于打包的轮廓矩的圆数据
  const momentCircles = []
  // 遍历所有轮廓
  contoursForEach: for (let i = 0; i < contoursSize; i++) {
  // 在空白Mat画布上画该轮廓
  cv.drawContours(
    // 空白Mat画布
    contoursHandleMat,
    // 轮廓组
    matVectorObj.contours,
    // 轮廓索引
    i,
    // 轮廓颜色
    contoursColor,
    // 轮廓线条粗细
    2
  )
  // 获取某个轮廓
  const contour = matVectorObj.contours.get(i)
  // 轮廓的矩（moments）
  // 1. 空间矩（Spatial Moments）
  //   m00：零阶矩，[轮廓面积]，即轮廓或点的总数（如果是图像，则为非零像素的总数）
  //   m10, m01：一阶矩，分别表示轮廓或点的质心的row/W和col/H坐标（需除以m00）
  //   m20, m11, m02：二阶矩，用于描述轮廓或点的分布
  // 2. 中心矩（Central Moments）
  //   mu20, mu11, mu02：二阶中心矩，描述了轮廓或点相对于其质心的分布
  //   mu30, mu21, mu12, mu03：三阶中心矩，用于描述轮廓或点的对称性
  // 3. 归一化中心矩（Normalized Central Moments）
  //   nu20, nu11, nu02：二阶归一化中心矩
  //   nu30, nu21, nu12, nu03：三阶归一化中心矩
  //   归一化中心矩用于描述轮廓或点的形状，这些矩是尺度不变的
  const moments = cv.moments(contour, true)
  // 如果轮廓面积为0，则直接遍历下一个轮廓
  if (moments.m00 === 0) {
    continue contoursForEach
  }
  // console.log(`轮廓质心：${ momentColAsX.toFixed(2) }, ${ momentRawAsY.toFixed(2) }`)
  // // 轮廓的周长
  // const arcLength = cv.arcLength(
  //   // 轮廓
  //   contour,
  //   // 轮廓的封闭性
  //   true
  // )
  // // 封闭轮廓的面积，其实就是零阶矩
  // const contourArea = cv.contourArea(
  //   // 轮廓
  //   contour
  // )
  // 获取该轮廓的最小外接圆。有center.x, center.y, radius
  const circle = cv.minEnclosingCircle(contour)
  // 计算最小外接圆面积
  const minEnclosingCircleArea = Math.PI * circle.radius * circle.radius
  // 计算“近圆率”
  const circleRate = moments.m00 / minEnclosingCircleArea
  // 如果近圆率小于设定值，则直接弃去，遍历下一个轮廓
  if (circleRate < argObj.minCircleRate) {
    continue contoursForEach
  }
  // 将近圆率筛选下来的轮廓加进临时轮廓数组
  momentCircles.push({
    // 画轮廓用的圆心和最小外接圆半径
    center: circle.center,
    radius: circle.radius,
    // 用轮廓矩计算得到轮廓质心
    centerY: (moments.m01 / moments.m00),
    centerX: (moments.m10 / moments.m00),
  })
  }
  // 然后是尺寸排序。按照半径从小到大重排轮廓数组
  momentCircles.sort((circleA, circleB) => {
  return (circleA.radius - circleB.radius)
  })
  // 然后过滤掉大的
  const countIndex = Math.floor(momentCircles.length * argObj.filterRate)
  // 遍历最终剩下的轮廓，此时遍历到的轮廓是合适的
  for (let i = 0; i < countIndex; i++) {
  // 在空白Mat画布上画最小外接圆
  cv.circle(
    // 空白Mat画布
    contoursHandleMat,
    // 最小外接圆圆心
    momentCircles[i].center,
    // 最小外接圆半径
    momentCircles[i].radius,
    // 最小外接圆颜色
    circleColor,
    // 最小外接圆线条粗细
    2
  )
  }
  // 导出轮廓矩的圆数据给全局对象
  argObj.momentCircles = momentCircles.slice(0, countIndex)
  // 修改ref标记
  isGetMomentCircles.value = true
  // 渲染画布
  cv.imshow(domObj.canvasContours, contoursHandleMat)
} catch (error) {
  console.error("contoursHandle()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "轮廓处理操作报错",
  content: error,
  })
}}

/**
 * @originResultToAoaoa 获取原始结果的AOAOA数据
 * @function
 * @returns {Array} 原始结果的AOAOA数据，三阶数组
 * @note 需读取argObj.momentCircles
 */
function originResultToAoaoa() { try {
  // 获取排序了的圆数据
  const sequenceCircleAoa = circleSequenceAoa(argObj.momentCircles)
  // 声明一个三阶数组，用于装结果数据
  const resultAoaoa = [
  // R
  [["距离"]],
  // G
  [["距离"]],
  // B
  [["距离"]],
  ]
  // 遍历圆数据的每一行
  for (let row = 0; row < sequenceCircleAoa.length; row++) {
  // 遍历圆数据每一行的每一列
  for (let col = 0; col < sequenceCircleAoa[row].length; col++) {
    // 获取该圆的逐圈RGB数据
    const hierarchyRGBAoaoa = getHierarchyRGB(
    sequenceCircleAoa[row][col],
    // 行，+1以符合人文理解
    row + 1,
    // 列，+1以符合人文理解
    col + 1
    )
    // 把逐圈RGB数据丢进结果三阶数组里
    for (let i = 0; i < resultAoaoa.length; i++) {
    // 如果当前行结果数组长度小于当前行轮廓数据长度，
    if (resultAoaoa[i][0].length < hierarchyRGBAoaoa[i][0].length) {
      // 则直接替换距离数组
      resultAoaoa[i][0] = hierarchyRGBAoaoa[i][0]
    }
    // 更新AVE和SD数组
    resultAoaoa[i].push(hierarchyRGBAoaoa[i][1])
    resultAoaoa[i].push(hierarchyRGBAoaoa[i][2])
    }
  }
  }
  return resultAoaoa
} catch (error) {
  console.error("originResultToAoaoa()报错: ", error)
}}

/**
 * @originResultToXlsx 获取原始结果并输出为xlsx文件
 * @function
 * @note 需读取argObj.momentCircles
 */
 function originResultToXlsx() { try {
  // 先调用originResultToAoaoa得到原始数据的AOAOA数据对象
  const resultAoaoa = originResultToAoaoa()
  // 得到结果，生成表格文件（记得转置）
  const sheetR = XLSX.utils.aoa_to_sheet(arrayTranspose(resultAoaoa[0]))
  const sheetG = XLSX.utils.aoa_to_sheet(arrayTranspose(resultAoaoa[1]))
  const sheetB = XLSX.utils.aoa_to_sheet(arrayTranspose(resultAoaoa[2]))
  // 创建一个新的工作簿对象
  const workbook = XLSX.utils.book_new()
  // 将工作表添加到工作簿
  XLSX.utils.book_append_sheet(workbook, sheetR, "R通道")
  XLSX.utils.book_append_sheet(workbook, sheetG, "G通道")
  XLSX.utils.book_append_sheet(workbook, sheetB, "B通道")
  // 以工作簿生成ArrayBuffer对象
  const workbookArrBufObj = XLSX.write(workbook, { type: "buffer" })
  // 以xlsx格式下载该工作簿ArrayBuffer对象
  downloadFile(workbookArrBufObj, "计算机视觉比色法_原始数据.xlsx")
} catch (error) {
  console.error("originResultToXlsx()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "获取原始结果并输出为xlsx文件操作报错",
  content: error,
  })
}}

/**
 * @originResultToLinechart 原始数据结果作图
 * @function
 * @note 需读取argObj.momentCircles
 */
function originResultToLinechart() { try {
  // 显示加载圈
  uni.showLoading({ title: "正在处理数据并绘图..." })
  // 先调用originResultToAoaoa得到原始数据的AOAOA数据对象
  const resultAoaoa = originResultToAoaoa()
  // 从AOAOA中提取标题，标题为“12行-15列-AVE”中的“12”和“15”
  const titleMatch = /\d+/g
  // 方便索引用的数组
  const nameMap = [
  "R通道（图例：行-列）",
  "G通道（图例：行-列）",
  "B通道（图例：行-列）"
  ]
  // 遍历RGB
  for (let i = 0; i < resultAoaoa.length; i++) {
  // x/y轴作图参数框
  const visOptions = {
    // x轴数据名称
    xLabel: "质心距离范围R",
    // y轴数据名称
    yLabel: "标准差SD",
  }
  // 作图的数据筐
  const chartDataAoa = []
  // // x轴数据。不需要了，因为天生从0开始
  // const xArr = resultAoaoa[i][0].slice(1)
  // y数据名称序列
  const serieTagArr = []
  // 遍历SD内容，从第3个开始，因为第1个是标题，第2n个是AVE
  for (let j = 2; j < resultAoaoa[i].length; j = j + 2) {
    // 正则表达式匹配序列名称中的数字
    const titleMatchs = resultAoaoa[i][j][0].match(titleMatch)
    // 把数字组成的编号推进y数据名称序列
    serieTagArr.push(`${ titleMatchs[0] }-${ titleMatchs[1] }`)
    // 把数据推进作图的筐里
    chartDataAoa.push({
    // x不用了
    // x: xArr,
    // y，从第2个开始，因为第1个是标题
    y: resultAoaoa[i][j].slice(1),
    })
  }
  // chartDataAoa统一每个数组长度，因为有的数组长度不够，导致图表不显示
  // 先接第一个成员数组长度
  let chartDataLength = chartDataAoa[0].y.length
  // 遍历chartDataAoa数组
  for (let k = 0; k < chartDataAoa.length; k++) {
    // 如果当前成员数组长度小于chartDataLength
    if (chartDataAoa[k].y.length < chartDataLength) {
    // 则把当前成员数组长度赋值给chartDataLength
    chartDataLength = chartDataAoa[k].y.length
    }
  }
  // 二次遍历chartDataAoa数组
  for (let k = 0; k < chartDataAoa.length; k++) {
    // 将所有成员数组长度统一为chartDataLength
    chartDataAoa[k].y.length = chartDataLength
  }
  // 作图
  aoaTotfvisChart(
    // 图表类型
    "linechart",
    // 图表容器
    { tab: "SD-R绘图预览", name: nameMap[i] },
    // 图表数据
    chartDataAoa,
    // 数据序列标签
    serieTagArr,
    // 额外参数
    visOptions
  )
  }
  // 把图表栏打开
  showVisor()
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("originResultToLinechart()报错: ", error)
  uni.hideLoading()
  uni.showModal({
  showCancel: false,
  title: "获取原始结果并渲染图表操作报错",
  content: error,
  })
}}

/**
 * @finalResultToXlsx 获取最终结果并输出为xlsx文件
 * @function
 * @note 需读取argObj.maxSd、argObj.momentCircles
 */
function finalResultToXlsx() { try {
  // 获取排序了的圆数据
  const sequenceCircleAoa = circleSequenceAoa(argObj.momentCircles)
  // 声明一个二阶数组，用于装结果数据
  const resultAoa = [
  ["行次"],
  ["列次"],
  ["距离"],
  ["R_AVE"],
  ["G_AVE"],
  ["B_AVE"],
  ]
  // 遍历圆数据的每一行
  for (let row = 0; row < sequenceCircleAoa.length; row++) {
  // 遍历圆数据每一行的每一列
  for (let col = 0; col < sequenceCircleAoa[row].length; col++) {
    // 以最大SD阈值范围计算圆的RGB值
    const maxSdRGB = getMaxSdRGB(
    // 圆数据
    sequenceCircleAoa[row][col],
    // 最大SD阈值
    argObj.maxSd
    )
    // 把maxSdRGB数据丢进结果二阶数组里
    resultAoa[0].push(row + 1)
    resultAoa[1].push(col + 1)
    resultAoa[2].push(maxSdRGB.distance)
    resultAoa[3].push(maxSdRGB.r)
    resultAoa[4].push(maxSdRGB.g)
    resultAoa[5].push(maxSdRGB.b)
  }
  }
  // 得到结果，生成表格文件（记得转置）
  const sheet = XLSX.utils.aoa_to_sheet(arrayTranspose(resultAoa))
  // 创建一个新的工作簿对象
  const workbook = XLSX.utils.book_new()
  // 将工作表添加到工作簿
  XLSX.utils.book_append_sheet(workbook, sheet, "结果数据")
  // 以工作簿生成ArrayBuffer对象
  const workbookArrBufObj = XLSX.write(workbook, { type: "buffer" })
  // 以xlsx格式下载该工作簿ArrayBuffer对象
  downloadFile(workbookArrBufObj, "计算机视觉比色法_结果数据.xlsx")
} catch (error) {
  console.error("finalResultToXlsx()报错: ", error)
  uni.showModal({
  showCancel: false,
  title: "获取最终结果并输出为xlsx文件操作报错",
  content: error,
  })
}}

/**
 * @下面的都是辅助函数
 */

/**
 * @circlesToRowSequence 根据所有圆数据得到行序列标准
 * @function
 * @param {{
 * 	centerY: Number,
 * 	radius: Number
 * }[] } circlesData 圆数据
 * @return {{
 * 	minY: Number,
 * 	maxY: Number,
 * }[] } 每一行的范围数组
 * 根据所有圆数据得到行序列标准，以便于排序
 */
 function circlesToRowSequence(circlesData) { try {
  // 先定排序的规则，也就是几个Y的范围框
  const circleGroups = [{
  minY: (circlesData[0].centerY - circlesData[0].radius),
  maxY: (circlesData[0].centerY + circlesData[0].radius)
  }]
  // 遍历所有轮廓数据
  circlesDataForEach: for (let i = 0; i < circlesData.length; i++) {
  // 遍历所有Y规则范围
  circleGroupsForEach: for (let j = 0; j < circleGroups.length; j++) {
    // 如果圆的质心在已有Y规则范围内
    if (
    (circlesData[i].centerY >= circleGroups[j].minY)
    && (circlesData[i].centerY <= circleGroups[j].maxY)
    // 则比较轮廓半径，取最值
    ) {
    // 最小值
    if ((circlesData[i].centerY - circlesData[i].radius) < circleGroups[j].minY) {
      circleGroups[j].minY = (circlesData[i].centerY - circlesData[i].radius)
    }
    // 最大值
    if ((circlesData[i].centerY + circlesData[i].radius) > circleGroups[j].maxY) {
      circleGroups[j].maxY = (circlesData[i].centerY + circlesData[i].radius)
    }
    // 跳出当前Y规则范围遍历，继续遍历下一个轮廓数据
    continue circlesDataForEach
    // 如果圆心不在已有Y规则范围内，且已遍历完所有Y规则范围
    } else if (j == circleGroups.length - 1) {
    // 推入新规则
    circleGroups.push({
      minY: (circlesData[i].centerY - circlesData[i].radius),
      maxY: (circlesData[i].centerY + circlesData[i].radius)
    })
    }
  }
  }
  // 遍历完毕，规则更新完毕，开始对规则排序
  circleGroups.sort((groupA, groupB) => {
  // 返回Y规则范围的Y差值
  return ((groupA.minY + groupA.maxY) - (groupB.minY + groupB.maxY))
  })
  // 返回排序后的规则
  return circleGroups
} catch (error) {
  console.error("circlesToRowSequence()报错: ", error)
}}

/**
 * @circleSequenceAoa 圆的有序AOA序列
 * @function
 * @param {{
 * 	centerX: Number,
 * 	centerY: Number,
 * 	radius: Number
 * }[] } oldCircles 待排序的圆数据
 * @return {{
 * 	centerX: Number,
 * 	centerY: Number,
 * 	radius: Number
 * }[][] } 排过序的圆数据
 * 外序是行，内序是列，即AOA序列
 */
function circleSequenceAoa(oldCircles) { try {
  // 获取行row(Y)排序数据
  const circleGroups = circlesToRowSequence(oldCircles)
  // 声明一个新数组AOA用于接收排序后的圆数据
  const newCircleAoa = new Array(circleGroups.length)
  // 二阶化
  for (let i = 0; i < circleGroups.length; i++) {
  newCircleAoa[i] = []
  }
  // 重新遍历老数组
  momentCirclesForEach: for (let i = 0; i < oldCircles.length; i++) {
  // 遍历规则数组
  circleGroupsForEach: for (let j = 0; j < circleGroups.length; j++) {
    // 如果圆心在规则范围内
    if (oldCircles[i].centerY >= circleGroups[j].minY
    && oldCircles[i].centerY <= circleGroups[j].maxY) {
    // 则把该圆推入新数组
    newCircleAoa[j].push(oldCircles[i])
    }
  }
  }
  // 然后再遍历新数组AOA，以X重新排序
  newCircleAoaForEach: for (let i = 0; i < newCircleAoa.length; i++) {
  newCircleAoa[i].sort((circleA, circleB) => {
    return (circleA.centerX - circleB.centerX)
  })
  }
  // 返回新数组AOA
  return newCircleAoa
} catch (error) {
  console.error("circleSequenceAoa()报错: ", error)
}}

/**
 * @findAllCirclePoints 找到轮廓内的所有点
 * @function
 * @param { Number } radius 给定半径
 * @return {{
 * 	deltaX: Number,
 * 	deltaY: Number,
 * 	distance: Number
 * }[] } 坐标矢量数组
 * 对于给定半径，以(0, 0)为中心，以距离排序，以德尔塔值形式给出所有坐标矢量
 */
function findAllCirclePoints(radius) { try {
  // 边界值
  const border = Math.floor(radius)
  // 搞个像素点的框
  const points = []
  // 遍历边界内的所有x、y点
  xsForEach: for (let x = -border; x <= border; x++) {
  ysForEach: for (let y = -border; y <= border; y++) {
    // 计算距离
    const distance = Math.sqrt(x * x + y * y)
    // 如果该点在半径外，即距离大于半径
    if (distance > radius) {
    // 则弃去这一轮，直接回到大遍历
    continue xsForEach
    }
    // 如果该点在半径内，则保留下来
    const point = {
    deltaX: x,
    deltaY: y,
    distance: distance
    }
    // 推进框里
    points.push(point)
  }
  }
  // 按照距离对框里的像素点排序
  points.sort((pointA, pointB) => {
  return (pointA.distance - pointB.distance)
  })
  // 返回像素点框
  return points
} catch (error) {
  console.error("findAllCirclePoints()报错: ", error)
}}

/**
 * @average 平均值
 * @function
 * @param { Number[] } arr 数组
 * @return { Number } 平均值
 */
function average(arr) { try {
  let sum = 0
  for (let i = 0; i < arr.length; i++) {
  sum = sum + arr[i]
  }
  return (sum / arr.length)
} catch (error) {
  console.error("average()报错: ", error)
}}

/**
 * @average 标准差SD
 * @function
 * @param { Number[] } arr 数组
 * @return { Number } 标准差
 */
function standardDeviation(arr) { try {
  const avg = average(arr)
  let sum = 0
  for (let i = 0; i < arr.length; i++) {
  sum = sum + Math.pow((arr[i] - avg), 2)
  }
  return Math.sqrt((sum / arr.length))
} catch (error) {
  console.error("standardDeviation()报错: ", error)
}}

/**
 * @getHierarchyRGB 找到某个轮廓的各层级的RGB值
 * @function
 * @param {{
 * 	centerX: Number,
 * 	centerY: Number,
 * 	radius: Number
 * }} circleDatum 圆数据
 * @param { Number } row 行
 * @param { Number } col 列
 * @return { Any[][][] } 结果数组，一个AOAOA三阶数组  
 * 	第0阶：resultAoaoa[i]。i是：[0]r、[1]g、[2]b  
 * 	第1阶：resultAoaoa[i][j]。j是：[0]距离标签数组，[1]是平均值数组，[2]是标准差数组  
 * 	第2阶：resultAoaoa[i][j][k]。k是2阶数组里具体的值  
 * 对于给定的轮廓，给出其RGB值，并按照距离（层级）排序
 */
function getHierarchyRGB(circleDatum, row, col) { try {
  // 声明一个用于返回结果的AOAOA三阶数组，具体格式见上方JSDoc注释
  // 构造器阶段是一阶的
  const resultAoaoa = new Array(3)
  // 给数组加头部标签数据，完成三阶数组的构造
  for (let i = 0; i < resultAoaoa.length; i++) {
  resultAoaoa[i] = [
    ["距离"],
    [`${ row }行-${ col }列-AVE`],
    [`${ row }行-${ col }列-SD`]
  ]
  }
  // 声明一个用于计算存放数据（便于计算AVE和SD）的AOA二阶数组
  // 第0阶：dataAoa[i]。i是：[0]r、[1]g、[2]b
  // 第1阶：dataAoa[i][j]。j是：2阶数组里具体的值
  const dataAoa = [[], [], []]
  // 获取所有范围内的相对点坐标
  const points = findAllCirclePoints(circleDatum.radius)
  // 走格子用的距离判据
  let distanceCriterion = 0
  // 遍历所有点
  for (let i = 0; i < points.length; i++) {
  // 如果遍历到的点的距离大于距离判据distanceCriterion
  if (points[i].distance > distanceCriterion) {
    // 遍历二阶数组，把数组里已有的数据计算AVE和SD
    for (let j = 0; j < dataAoa.length; j++) {
    // 遍历项为RGB，对于每个RGB：0是序号，1是平均值，2是标准差
    resultAoaoa[j][0].push(distanceCriterion)
    resultAoaoa[j][1].push(average(dataAoa[j]))
    resultAoaoa[j][2].push(standardDeviation(dataAoa[j]))
    }
    // 距离判据+1
    distanceCriterion++
  }
  // 获取像素点数据
  const pixelData = matObj.origin.ucharPtr(
    // 行row，即y
    circleDatum.centerY + points[i].deltaY,
    // 列col，即x
    circleDatum.centerX + points[i].deltaX
  )
  // 获取RGB值，推到待计算的二阶数组里
  dataAoa[0].push(pixelData[0])
  dataAoa[1].push(pixelData[1])
  dataAoa[2].push(pixelData[2])
  }
  // 返回结果
  return resultAoaoa
} catch (error) {
  console.error("getHierarchyRGB()报错: ", error)
}}

/**
 * @getMaxSdRGB 以最大SD阈值范围计算圆的RGB值
 * @function
 * @param {{
 * 	centerX: Number,
 * 	centerY: Number,
 * 	radius: Number
 * }} circleDatum 圆数据
 * @param { Number } maxSd 最大SD阈值
 * @return {{
 * 	r: Number,
 * 	g: Number,
 * 	b: Number,
 * 	distance: Number
 * }} 结果对象
 * 对于给定的轮廓，给定的最大SD阈值，RGB均不超过SD阈值的范围内的RGB均值
 */
function getMaxSdRGB(circleDatum, maxSd) { try {
  // 声明一个用于返回结果的对象
  const resultData = {
  r: null,
  g: null,
  b: null,
  distance: 0
  }
  // 声明一个用于计算存放数据（便于计算AVE和SD）的AOA二阶数组
  // 第0阶：dataAoa[i]。i是：[0]r、[1]g、[2]b
  // 第1阶：dataAoa[i][j]。j是：2阶数组里具体的值
  const dataAoa = [[], [], []]
  // 获取所有范围内的相对点坐标
  const points = findAllCirclePoints(circleDatum.radius)
  // 遍历所有点
  pointsForEach: for (let i = 0; i < points.length; i++) {
  // 如果遍历到的点的距离大于距离
  if (points[i].distance > resultData.distance) {
    // 要开始汇总之前的距离的数值了，先算SD
    const newSdR = standardDeviation(dataAoa[0])
    const newSdG = standardDeviation(dataAoa[1])
    const newSdB = standardDeviation(dataAoa[2])
    // 如果R、G、B的SD有任何一个大于最大SD阈值
    if ((newSdR > maxSd) || (newSdG > maxSd) || (newSdB > maxSd)) {
    // 说明超标了，终止当前循环
    break pointsForEach
    // 反之，若R、G、B的SD均不大于最大SD阈值
    } else {
    // 可以汇总该距离的数值
    resultData.distance++
    resultData.r = average(dataAoa[0])
    resultData.g = average(dataAoa[1])
    resultData.b = average(dataAoa[2])
    }
  }
  // 遍历常规操作：获取数据，推进数组里
  // 获取像素点数据
  const pixelData = matObj.origin.ucharPtr(
    // 行row，即y
    circleDatum.centerY + points[i].deltaY,
    // 列col，即x
    circleDatum.centerX + points[i].deltaX
  )
  // 获取RGB值，推到待计算的二阶数组里
  dataAoa[0].push(pixelData[0])
  dataAoa[1].push(pixelData[1])
  dataAoa[2].push(pixelData[2])
  }
  // 返回结果
  return resultData
} catch (error) {
  console.error("getMaxSdRGB()报错: ", error)
}}
  
</script>
